File Descriptor
contents
리눅스(Linux), macOS 및 기타 유닉스(UNIX) 계열 운영체제에는 아주 유명한 철학이 있습니다: "모든 것은 파일이다(Everything is a file)."
여러분의 프로그램이 텍스트 파일을 읽든, 네트워크 연결을 통해 데이터를 보내든, 키보드에서 입력을 받든, USB 장치와 통신하든 간에 운영체제는 이 모든 상호작용을 완전히 동일한 방식으로 처리합니다.
이 수많은 연결들을 추적하고 관리하기 위해 운영체제는 파일 디스크립터(File Descriptors, FD)를 사용합니다.
파일 디스크립터가 정확히 무엇인지, 내부적으로 어떻게 작동하는지, 그리고 운영체제가 이를 어떻게 관리하는지에 대한 아주 상세한 분석입니다.
1. 핵심 개념: 파일 디스크립터란 무엇인가?
파일 디스크립터는 단순히 음수가 아닌 정수(0, 1, 2, 3 등)입니다.
프로그램이 운영체제(OS)에게 파일, 네트워크 소켓, 또는 하드웨어 장치를 열어달라고 요청하면, OS는 연결을 설정하는 모든 복잡한 작업을 뒤에서 처리합니다. 그리고 프로그램에게 원시 메모리 주소나 하드웨어 세부 정보를 주는 대신, 단순한 정수—일종의 "대기표 번호"—를 하나 건네줍니다.
여러분의 프로그램은 앞으로의 모든 작업에 이 대기표 번호(FD)를 사용합니다.
- "OS야, FD 4번에서 50바이트만 읽어줘."
- "OS야, 이 문자열을 FD 5번에 써줘."
- "OS야, FD 4번 다 썼으니까 이제 닫아줘."
2. "마법의 3": 표준 스트림 (Standard Streams)
새로운 프로세스(실행 중인 프로그램)가 시작될 때마다, OS는 기본적으로 3개의 파일 디스크립터를 자동으로 엽니다. 여러분은 매일 이걸 무의식적으로 사용하고 있습니다.
| 파일 디스크립터 (FD) | 약어 | 이름 | 목적 |
|---|---|---|---|
| 0 | stdin |
표준 입력 (Standard Input) | 프로그램이 입력을 읽어 들이는 곳 (기본값: 키보드). |
| 1 | stdout |
표준 출력 (Standard Output) | 프로그램이 일반적인 출력 결과를 쓰는 곳 (기본값: 터미널 화면). |
| 2 | stderr |
표준 에러 (Standard Error) | 프로그램이 에러 메시지를 쓰는 곳 (기본값: 터미널 화면). |
만약 Python 스크립트를 작성하고 print("Hello")를 실행하면, Python은 내부적으로 OS에게 이렇게 말합니다: "문자열 'Hello'를 파일 디스크립터 1번(표준 출력)에 써줘."
3. 아키텍처: 세 개의 테이블 (설계의 묘미)
파일 디스크립터를 진정으로 이해하려면 OS가 이를 어떻게 추적하는지 알아야 합니다. OS는 단순히 하나의 목록만 쓰지 않고, 고도로 공학적인 3단계 아키텍처를 사용합니다.
A. 프로세스 파일 디스크립터 테이블 (프로세스별 독립)
실행 중인 모든 프로그램(프로세스)은 자신만의 독립적인 배열(테이블)을 갖습니다.
- 배열의 인덱스가 바로 파일 디스크립터 정수(0, 1, 2, 3...)입니다.
- 해당 인덱스에 들어있는 값은 시스템 전체 열린 파일 테이블을 가리키는 포인터입니다.
- 이 테이블은 프로세스마다 완전히 독립적이기 때문에, 프로세스 A와 프로세스 B가 둘 다
4번 FD를 가지고 있더라도 서로 완전히 다른 파일을 가리킬 수 있습니다.
B. 시스템 전체 열린 파일 테이블 (시스템 공유)
커널(운영체제의 핵심)은 시스템 전체의 모든 열린 파일을 관리하기 위해 거대한 테이블을 딱 하나 유지합니다. 파일을 열면 여기에 항목이 생성됩니다. 이 항목에는 다음이 포함됩니다:
- 파일 오프셋(File Offset): 현재 데이터를 읽거나 쓰고 있는 커서의 위치 (예: 500번째 바이트).
- 상태 플래그: 읽기 전용으로 열었는가? 쓰기 전용인가? 덧붙이기(Append)인가?
- 참조 횟수(Reference Count): 현재 몇 개의 FD가 이 항목을 가리키고 있는지 나타내는 숫자.
- i-node 테이블을 가리키는 포인터.
C. i-node 테이블 (시스템 공유)
이 테이블은 하드 드라이브나 네트워크 인터페이스에 존재하는 실제 물리적 파일을 나타냅니다. i-node에는 다음이 포함됩니다:
- 파일의 크기.
- 파일의 권한 (읽기/쓰기/실행).
- 하드 드라이브 상의 데이터 블록들의 물리적 위치.
4. 왜 굳이 세 개의 테이블을 쓸까요? (설계의 천재성)
이렇게 분리하는 것이 복잡해 보일 수 있지만, 사실 아주 복잡한 문제들을 극도로 우아하게 해결해 줍니다:
시나리오 1: 독립적으로 같은 파일 열기
프로세스 A와 프로세스 B가 각각 독립적으로 database.txt를 열면, OS는 시스템 전체 열린 파일 테이블에 두 개의 독립적인 항목을 만듭니다. 하지만 이 두 항목은 동일한 i-node를 가리킵니다.
- 이유: 프로세스 A와 B는 각자만의 파일 오프셋(읽는 위치)을 가져야 하기 때문입니다. 프로세스 A는 10번째 줄을 읽고 있고, 프로세스 B는 500번째 줄을 읽고 있을 수 있으니까요.
시나리오 2: 프로세스 포크 (부모와 자식 프로세스)
어떤 프로세스가 자식 프로세스를 생성하면(fork() 사용), 자식은 부모의 FD 테이블을 그대로 복사해서 물려받습니다. 즉, 부모의 FD 4번과 자식의 FD 4번은 시스템 열린 파일 테이블의 완벽하게 똑같은 단일 항목을 가리키게 됩니다.
- 이유: 부모와 자식이 파일 오프셋을 공유해야 하기 때문입니다! 부모가 10바이트를 읽으면 오프셋(커서)이 앞으로 이동하고, 이어서 자식이 읽기를 시도하면 부모가 멈춘 바로 그 지점부터 이어서 읽게 됩니다.
5. 고급 메커니즘: 리다이렉션과 파이프
FD는 결국 테이블 항목을 가리키는 단순한 정수에 불과하기 때문에, OS는 이를 이용해 기발한 마술을 부릴 수 있습니다.
출력 리다이렉션 (>)
터미널에 echo "Hello" > file.txt라고 치면, 쉘(Shell)은 다음 작업을 수행합니다:
file.txt를 엽니다 (가령 FD 3번을 할당받았다고 합시다).dup2(3, 1)이라는 시스템 콜을 사용합니다. 이는 OS에게 이렇게 명령하는 것입니다: "FD 1번(표준 출력)이 FD 3번이 가리키고 있는 곳을 똑같이 가리키도록 덮어씌워라."- 이제
echo프로그램이 평소처럼 FD 1번에 출력을 내보내면, 데이터는 모니터 화면을 건너뛰고file.txt로 직행합니다.echo프로그램 자체는 이 사실을 전혀 모릅니다!
파이프 (|)
터미널에 ls | grep "txt"라고 치면, OS는 파이프(Pipe)를 만듭니다. 파이프는 파일처럼 작동하는 메모리 내부의 버퍼입니다. OS는 "읽기용" FD와 "쓰기용" FD, 총 두 개를 만듭니다.
ls프로그램의 FD 1번(stdout)을 파이프의 쓰기 단에 연결합니다.grep프로그램의 FD 0번(stdin)을 파이프의 읽기 단에 연결합니다.- 이제
ls의 출력 결과가grep의 입력으로 막힘없이 흘러 들어갑니다.
6. 한계치와 "열린 파일이 너무 많음(Too Many Open Files)" 에러
열려있는 모든 파일은 커널 테이블에서 메모리를 소모하기 때문에, 운영체제는 하나의 프로세스가 동시에 열 수 있는 FD의 개수에 엄격한 제한을 둡니다.
- 리눅스에서는
ulimit -n명령어로 이 제한을 확인할 수 있습니다 (보통 1024개가 기본값입니다). - 만약 프로그램이 파일을 열기만 하고 닫는 것을 잊어버리면("파일 디스크립터 누수"), 결국 이 제한에 부딪혀 악명 높은
EMFILE (Too many open files)에러를 뱉으며 크래시(Crash)가 납니다. 코드에서 파일이나 네트워크 소켓을 사용한 뒤 반드시close()로 닫아주는 것이 치명적으로 중요한 이유가 바로 이것입니다.
references